[!NOTE]
This is one of 199 standalone projects, maintained as part
of the @thi.ng/umbrella monorepo
and anti-framework.
🚀 Please help me to work full-time on these projects by sponsoring me on
GitHub. Thank you! ❤️
About
Mutable wrappers for nested immutable values with optional undo/redo history and transaction support.
Additional support for:
- type checked value access for up to 8 levels of nesting (deeper values
default to
any
) - watches (listeners)
- derived, eager/lazy view subscriptions (w/ optional transformation)
- cursors (direct R/W access to nested values)
- transacted updates (incl. nested transactions)
- undo/redo history
Together, these types act as building blocks for various application
state handling patterns, specifically aimed (though not exclusively) at
the concept of using a centralized atom around a nested, immutable
object as single source of truth within an application and driving
reactive updates from performed state changes.
Status
STABLE - used in production
Search or submit any issues for this package
Temporary restrictions
Due to a change of inferencing rules in TypeScript 4.3 with regards to tuples, the IReset
and ISwap
interface definitions in this package had to be updated and removed support for lookup path lengths > 6. This change is expected to be temporary only and is tracked by #303.
Breaking changes
4.0.0
Type checked accessors
The resetIn()
and swapIn()
methods are fully type checked (up to 8
levels deep), with the given value paths (and the new state value) being
validated against the structure of the containers's main value type.
Since that kind of type checking can only be done via tuples, string
paths are NOT supported anymore and instead require using the
resetInUnsafe()
and swapInUnsafe()
methods, which now provide the
legacy, unchecked update functionality. More details below.
The use of the Unsafe
suffix is inspired by Rust and, for consistency,
now used across other umbrella packages providing both checked and
unchecked variations.
Factory functions
Users are encouraged to use the new set of (type checked) factory
functions in lieu of direct constructor invocations. These functions are
now considered the defacto way to create new instances are generally
starting to be provided more consistently across the umbrella ecosystem.
All of them use the def
prefix, e.g. defAtom()
, defCursor()
etc.
Unsafe
versions exist for some types too. More
info
Deprecated
Derived views can now only be created via defView()
, defViewUnsafe()
(or new View()
). The IViewable
interface and .addView()
methods
have been removed.
Related packages
- @thi.ng/interceptors - Interceptor based event bus, side effect & immutable state handling
- @thi.ng/paths - Immutable, optimized and optionally typed path-based object property / array accessors with structural sharing
- @thi.ng/rstream - Reactive streams & subscription primitives for constructing dataflow graphs / pipelines
Installation
yarn add @thi.ng/atom
ESM import:
import * as atom from "@thi.ng/atom";
Browser ESM import:
<script type="module" src="https://esm.run/@thi.ng/atom"></script>
JSDelivr documentation
For Node.js REPL:
const atom = await import("@thi.ng/atom");
Package sizes (brotli'd, pre-treeshake): ESM: 2.08 KB
Dependencies
Note: @thi.ng/api is in most cases a type-only import (not used at runtime)
Usage examples
21 projects in this repo's
/examples
directory are using this package:
Screenshot | Description | Live demo | Source |
---|
| BMI calculator in a devcards format | Demo | Source |
| Interactive inverse FFT toy synth | Demo | Source |
| Custom dropdown UI component for hdom | Demo | Source |
| Custom dropdown UI component w/ fuzzy search | Demo | Source |
| Using custom hdom context for dynamic UI theming | Demo | Source |
| Hiccup / hdom DOM hydration example | Demo | Source |
| Canvas based Immediate Mode GUI components | Demo | Source |
| Basic SPA example with atom-based UI router | Demo | Source |
| Basic usage of thi.ng/rdom keyed list component wrapper | Demo | Source |
| Basic thi.ng/router usage with thi.ng/rdom components | Demo | Source |
| rdom powered SVG graph with draggable nodes | Demo | Source |
| Animated Voronoi diagram, cubic splines & SVG download | Demo | Source |
| Complete mini SPA app w/ router & async content loading | Demo | Source |
| Minimal rstream dataflow graph | Demo | Source |
| Interactive grid generator, SVG generation & export, undo/redo support | Demo | Source |
| rstream based spreadsheet w/ S-expression formula DSL | Demo | Source |
| Declarative component-based system with central rstream-based pubsub event bus | Demo | Source |
| Additive waveform synthesis & SVG visualization with undo/redo | Demo | Source |
| Obligatory to-do list example with undo/redo | Demo | Source |
| Multi-layer vectorization & dithering of bitmap images | Demo | Source |
| Triple store query results & sortable table | Demo | Source |
API
Generated API docs
Atom
An Atom
is a mutable (typed) wrapper for supposedly immutable values.
The wrapped value can be obtained via deref()
, replaced via reset()
and updated using swap()
. An atom too supports the concept of watches,
essentially onchange
event handlers which are called from reset
/
swap
and receive both the old and new atom values.
Note: The IDeref
interface
is widely supported across many thi.ng/umbrella packages and implemented
by most value wrapper types.
import { defAtom } from "@thi.ng/atom";
const a = defAtom(23);
a.deref();
a.addWatch("foo", (id, prev, curr) => console.log(`${id}: ${prev} -> ${curr}`));
const add = (x, y) => x + y;
a.swap(add, 1);
a.reset(42);
When Atom
-like containers are used to wrap nested object values, the
resetIn()
/ swapIn()
methods can be used to directly update nested
values. These updates are handled via immutable setters provided by
@thi.ng/paths.
import { defAtom } from "@thi.ng/atom";
const db = defAtom({ a: { b: 1, c: 2 } });
db.resetIn(["a", "b"], 100);
db.resetInUnsafe("a.b", 100);
db.swapIn(["a", "c"], (x) => x + 1);
db.swapInUnsafe("a.c", (x) => x + 1);
If the update path is created dynamically, you will have to use one of
these approaches:
interface Item {
name: string;
}
const db = defAtom<Item[]>([{ name: "thi.ng" }, { name: "atom" }]);
const id = 1;
db.resetIn(<const>[id, "name"], "umbrella");
db.resetInUnsafe([id, "name"], "umbrella");
db.resetInUnsafe(`${id}.name`, "umbrella");
Transacted updates
Since v3.1.0, multiple sequential state updates can be grouped in
transactions and then applied in one go (or canceled altogether). This
can be useful to produce a clean(er) sequence of undo snapshots (see
further below) and avoids multiple / obsolete invocations of watches
caused by each interim state update. Using a transaction, the parent
state is only updated once and watches too are only notified once after
each commit.
Transactions can also be canceled, thus not impacting the parent state
at all.
Transacted
can wrap any existing
IAtom implementation,
e.g. Atom
, Cursor
or History
instances. Transacted
also implements
IAtom
itself...
import { defAtom, defTransacted } from "@thi.ng/atom";
const db = defAtom<any>({ a: 1, b: 2 });
const tx = defTransacted(db);
tx.begin();
tx.resetIn(["a"], 11);
tx.resetIn(["c"], 33);
tx.deref()
db.deref()
tx.commit();
db.deref()
Nested transactions
Nested transactions on a single Transacted
instance are not
supported and attempting to do so will throw an error. However, nested
transactions can be achieved by wrapping another Transacted
container.
import { beginTransaction } from "@thi.ng/atom";
const tx1 = beginTransaction(db);
tx1.resetIn(["a"], 10);
const tx2 = beginTransaction(tx1);
tx2.resetIn(["b"], 20);
tx2.commit();
tx1.commit();
db.deref();
External modifications during active transaction
An error will be thrown if the parent change receives any updates whilst
a transaction is active. This is to guarantee general data integrity and
to signal race conditions due to erroneous / out-of-phase state update
logic.
import { beginTransaction } from "@thi.ng/atom";
const tx = beginTransaction(db);
tx.resetIn(["a"], 10);
db.resetIn(["a"], 2);
Cursor
Cursors provide direct & immutable access to a nested value within a
structured atom. The path to the desired value must be provided when the
cursor is created and cannot be changed later. Since v4.0.0, the path
itself is type checked and MUST be compatible with the type of the
parent state (or use defCursorUnsafe()
as fallback, see breaking
changes). The path is then compiled into a getter
and
setter
to allow cursors to be used like atoms and update the parent state in an
immutable manner (i.e. producing an optimized copy with structural
sharing of the original (as much as possible)) - see further details
below.
It's important to remember that cursors also cause their parent
state (atom or another cursor) to reflect their updated local state.
I.e. any change to a cursor's value propagates up the hierarchy of
parent states and also triggers any watches attached to the parent.
import { defAtom, defCursor } from "@thi.ng/atom";
a = defAtom({a: {b: {c: 1}}})
b = defCursor(a, "a.b")
c = defCursor(b, "c")
c.reset(2);
b.deref();
a.deref();
For that reason, it's recommended to design the overall data layout
rather wide than deep (my personal limit is 3-4 levels) to minimize the
length of the propagation chain and maximize structural sharing.
import { defAtom, defCursor, defCursorUnsafe } from "@thi.ng/atom";
main = defAtom({ a: { b: { c: 23 }, d: { e: 42 } }, f: 66 });
cursor = defCursor(main, ["a", "b", "c"]);
cursor = defCursorUnsafe<number>(main, "a.b.c");
cursor.addWatch("foo", console.log);
cursor.deref();
cursor.swap(x => x + 1);
main.deref();
Derived views
Whereas cursors provide read/write access to nested key paths within a
state atom, there are many situations when one only requires read access
and the ability to (optionally) produce transformed versions of such a
value. The View
type provides exactly this functionality:
import { defAtom, defView, defViewUnsafe } from "@thi.ng/atom";
db = defAtom({ a: 1, b: { c: 2 } });
viewA = defView(db, ["a"]);
viewC = defView(db, ["b","c"], (x) => x * 10);
viewC = defViewUnsafe(db, "b.c", (x) => x * 10);
viewA.deref()
viewC.deref()
db.resetIn(["b","c"], 3)
viewA.changed()
viewC.changed()
viewC.deref()
viewC.deref()
viewA.release()
viewC.release()
Since v1.1.0 views can also be configured to be eager, instead of the
"lazy" default behavior. If the optional lazy
arg is true (default),
the view's transformer will only be executed with the first deref()
after each value change. If lazy
is false, the transformer function
will be executed immediately after a value change occurred and so can be
used like a selective watch which only triggers if there was an actual
value change (in contrast to normal watches, which execute with each
update, regardless of value change).
Related, the actual value change predicate can be customized. If not
given, the default @thi.ng/equiv
will be used.
import { defAtom, defView } from "@thi.ng/atom";
let x = 0;
let a = defAtom({ value: 1 })
view = defView(a, ["value"], (y) => (x = y, y * 10), false);
x === 1
x = 0
view.deref() === 10
x === 0
Atoms & views are useful tools for keeping state outside UI components.
Here's an example of a tiny
@thi.ng/hdom
web app, demonstrating how to use derived views to switch the UI for
different application states / modules.
Note: The constrained nature of this next example doesn't really do
justice to the powerful nature of the approach. Also stylistically, in a
larger app we'd want to avoid the use of global variables (apart from
db
) as done here...
For a more advanced / realworld usage pattern, check the related event
handling
package
and bundled
examples.
This example is also available in standalone form:
Source | Live demo
import type { Nullable, Path } from "@thi.ng/api";
import { defAtom, defView } from "@thi.ng/atom";
import { start } from "@thi.ng/hdom";
import { capitalize } from "@thi.ng/strings";
interface State {
state: string;
error?: string;
user: {
name?: string;
};
}
const db = defAtom<State>({ state: "login", user: {} });
const appState = defView(db, ["state"]);
const error = defView(db, ["error"], (error) =>
error ? ["div.error", error] : null
);
const user = defView(db, ["user", "name"], (name) =>
name ? capitalize(name) : null
);
const setState = (s: string) => setValue(appState.path, s);
const setError = (err: Nullable<string>) => setValue(error.path, err);
const setUser = (e: Event) => setValue(user.path, (<any>e.target).value);
const setValue = (path: Path, val: any) => db.resetInUnsafe(path, val);
const loginUser = () => {
if (user.deref() === "admin") {
setError(null);
setState("main");
} else {
setError("sorry, wrong username (try 'admin')");
}
};
const logoutUser = () => {
setValue(user.path, null);
setState("logout");
};
const uiViews: any = {
login: () => [
"div#login",
["h1", "Login"],
error.deref(),
["input", { type: "text", onchange: setUser }],
["button", { onclick: loginUser }, "Login"],
],
logout: () => [
"div#logout",
["h1", "Good bye"],
"You've been logged out. ",
["a", { href: "#", onclick: () => setState("login") }, "Log back in?"],
],
main: () => [
"div#main",
["h1", `Welcome, ${user.deref()}!`],
["div", "Current app state:"],
[
"div",
[
"textarea",
{ cols: 40, rows: 10 },
JSON.stringify(db.deref(), null, 2),
],
],
["button", { onclick: logoutUser }, "Logout"],
],
};
const currView = defView(
db,
["state"],
(state) => uiViews[state] || ["div", ["h1", `No component for state: ${state}`]]
);
start(() => ["div", currView]);
Undo / Redo history
The History
type can be used with & behaves just like an Atom
or
Cursor
, but too creates snapshots of the current state before applying
the new state. These snapshots are stored in a doubly-linked list and
can be navigated via .undo()
/ .redo()
. Each time one of these
methods is called, the parent state will be updated and any attached
watches are notified. By default, the history has length of 100 steps,
though this is configurable via ctor args.
import { defAtom, defHistory } from "@thi.ng/atom";
db = defHistory(defAtom({ a: 1 }), 100)
db.deref()
db.reset({ a: 2, b: 3 })
db.reset({ b: 4 })
db.undo()
db.undo()
db.undo()
db.canUndo()
db.redo()
db.redo()
db.redo()
db.canRedo()
Authors
If this project contributes to an academic publication, please cite it as:
@misc{thing-atom,
title = "@thi.ng/atom",
author = "Karsten Schmidt",
note = "https://thi.ng/atom",
year = 2017
}
License
© 2017 - 2024 Karsten Schmidt // Apache License 2.0